Repository navigation
Force one token refresh after an API 401, bound HTTP timeouts, and keep reauth reachable - #319
Draft
pjordanandrsn wants to merge 2 commits into
Draft
pjordanandrsn wants to merge 2 commits into
pjordanandrsn wants to merge 2 commits into
Conversation
Ported from pjordanandrsn/ha-generac#4 by @technorat2 (commits 7e3b067 "Improve authentication and entity reporting" and 40ae00b "Retry transient Mobile Link timeouts"), adapted to current upstream: - GeneracAuth.force_refresh(): mint a new access token when the resource server rejects the current one; get_endpoint() retries the request once with it. - Bound every auth and API request (45 s total, 10 s connect, 30 s read) and retry a timed-out API GET once. - Reauth/reconfigure replace stale credentials instead of merging them into the new entry data. - Coordinator treats OSError (timeouts, connection failures) as a transient UpdateFailed rather than an unexpected error. Adaptation notes: the login flow's dedicated session now carries the timeout once instead of per request; the discarded-key set names the credentials upstream's retired cookie/password flows actually stored (password, session_cookie). The resource server Authorization scheme stays Bearer. Co-authored-by: Jordan Anderson <paul.jordan.anderson@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K2RdXuPegztHA1uMG2fbtk
Follow-up to the ported 401 retry. get_endpoint() called force_refresh() inside the transport `except Exception` block, so an InvalidGrantError raised by the token endpoint was converted to InvalidCredentialsException and then wrapped again as IOError. The coordinator treated that as a transient failure and Home Assistant never started reauthentication. - Move the forced refresh out of the transport try/except. A revoked refresh token now surfaces as InvalidCredentialsException (ConfigEntryAuthFailed). A token-endpoint outage keeps its own type (UpdateFailed). - force_refresh(rejected_token): callers that were rejected with the same token share one refresh instead of each spending the refresh token. - Retry transient connection failures (aiohttp.ClientConnectionError) as well as timeouts. Each request gets at most one transport retry and at most one refresh-and-retry. - The coordinator marks itself offline on transient OSError failures, as every other failure branch does. - Make the config flow's async_remove a synchronous callback that schedules aclose(). Home Assistant calls it without awaiting, so the async override never ran and a login session paused on the MFA form leaked when the flow was abandoned. Tests cover a valid token, an expired token refreshed, 401 followed by a successful refresh, a persistent 401, a revoked refresh token, a refresh outage, a timeout then success, repeated timeouts and connection failures, concurrent callers, refresh-token rotation, secret hygiene on failed refreshes, the bounded timeouts, stale legacy credentials being dropped on reauth/reconfigure, and an abandoned MFA flow closing its login session. One end-to-end test goes from an API 401 through the real GeneracAuth/GeneracApiClient to ConfigEntryAuthFailed. Co-authored-by: technorat2 <technorat2@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K2RdXuPegztHA1uMG2fbtk
pjordanandrsn
force-pushed
the
claude/upstream-auth-reliability
branch
from
October 29, 2026 08:27
6d7d21d to
088e5ac
Compare
pjordanandrsn
added a commit
to pjordanandrsn/ha-generac
that referenced
this pull request
Oct 29, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion PR (independent, no ordering dependency): entity naming and telemetry, #320 (refs #284).
Problem
401from the Mobile Link resource API currently raisesSessionExpiredExceptionstraight away. Entities stay unavailable until the next poll, even when a single token refresh would have worked.entry.data({**entry.data, **entry_data}). Entries created by the retired cookie/password flows therefore keeppasswordandsession_cookieindefinitely.Behavior changes
GeneracAuth.force_refresh(rejected_token)mints a new access token. If a concurrent caller has already replaced the rejected token, it reuses that one instead of refreshing again, so concurrent callers can't spend the refresh token twice. A second401raisesSessionExpiredException(→UpdateFailed).try/except. A revoked refresh token (invalid_grant, 400/401/403) therefore reaches the coordinator asInvalidCredentialsException→ConfigEntryAuthFailed→ reauth. In the original patch it was wrapped asIOErrorand treated as transient, so reauth never started. A token-endpoint outage keeps its own type (UpdateFailed).aiohttp.ClientConnectionErroron a GET is retried once after 1 s, then raised asIOError. The transport retry and the 401 refresh retry each have their own single budget, so a request makes at most 3 attempts.OSError(timeouts, connection failures, theIOErrorabove) becomes a logged warning plusUpdateFailedinstead of an "unexpected error" with a traceback, and the coordinator is marked offline as in the other failure branches.passwordandsession_cookieand keep any other keys.main): the config flow'sasync_removeis now a synchronous@callbackthat schedulesaclose(). Home Assistant calls it without awaiting, so the oldasync defoverride never ran and a login session paused on the MFA form leaked. This also clears theGeneracFlowHandler.async_remove was never awaitedwarnings.Authorization: Bearer; nothing moves to DPoP (see the existing probe note inapi.py).Compatibility
username,refresh_token,dpop_pem); only stale legacy keys are removed, and only on reauth/reconfigure.Regression tests added
tests/test_api.py,tests/test_auth.py,tests/test_coordinator.py,tests/test_config_flow.py(+24 tests). All network I/O is mocked; nothing contacts Mobile Link or Auth0.SessionExpiredExceptionInvalidCredentialsException, explicitly notOSErrorGeneracAuth/GeneracApiClient→ 403invalid_grant→ConfigEntryAuthFailed. This test fails on the original patch withOSError: … InvalidCredentialsException: revoked.RuntimeErrorpassword/session_cookieasync_removefix)Validation (run locally, Python 3.14.8,
homeassistant==2026.7.4,pytest-homeassistant-custom-component==0.13.348fromrequirements.txt)pytest: 99 passed, no warnings (baseline onmain: 75 passed, 8async_remove was never awaitedwarnings).pre-commit run --all-files(check-yaml, end-of-file, trailing-whitespace, flake8, reorder-python-imports, black 23.3.0, prettier): all passedpython -m compileall custom_components: OK;git diff --check: cleanCommits and attribution
Retry once after an API 401 and bound Mobile Link HTTP timeouts: authored by @technorat2, ported from their commits 7e3b067 and 40ae00b and adapted to currentmain. It deliberately preserves the original logic, including the defect fixed in commit 2.Keep reauth reachable when the forced refresh is rejected: the exception-semantics fix, refresh dedupe, connection-error retry, theasync_removefix and tests (Co-authored-by: technorat2).If you squash-merge, GitHub keeps @technorat2 as a co-author.
Not carried over from the original PR
async_set_unique_id(email.casefold()): this changes config-entry identity for new entries only, which would make the duplicate check inconsistent with existing entries. It's out of scope here..strip()on user setup and removal of thesession_cookietranslation labels: already onmain(fix(auth): handle Auth0 password 400 without error code and strip email whitespace (#295) #310, ff0cc61).Limitations
UpdateFailedrather than reauth: the refresh token still works, so prompting for a password would not help.🤖 Generated with Claude Code